46 — 性能技巧
介绍
正如我们在最早的几节课之一里说过的,你至少应该以 60fps 的体验为目标。有些用户的配置甚至可能让体验跑出更高的帧率。那些通常是游戏玩家,他们对性能和帧率的要求更加苛刻。
主要有两种限制:
- CPU
- GPU
你需要留意性能,并在多种不同配置的设备上测试,如果你的网站应该兼容移动设备,也别忘了它们。
你还应该留意网站的整体体积。我们在本地开发时,加载快得惊人,但一旦上线,就取决于用户的连接和服务器速度了。我们需要让资源尽可能轻。
有很多技巧可以同时改善性能和体积,其中大部分我们已经见过,这里给出一份详尽的清单。
设置
下面的一些技巧在起始代码里有代码示例,每个技巧都有一个编号。如果你想测试,就取消注释对应的代码部分。
监控
首先,我们需要测量性能,而不是靠眼睛估。
1 - 监控 FPS
Chrome 曾经有一个不错的 FPS 计量器,但现在没有了。取而代之,我们可以用一个 JavaScript FPS 计量器,比如 stats.js。
用 npm install stats.js 把它加到依赖里。
导入并实例化它
import Stats from 'stats.js'
const stats = new Stats()
stats.showPanel(0) // 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild(stats.dom) 在 tick 函数里调用它的 begin() 和 end() 方法
const tick = () =>
{
stats.begin()
// ...
stats.end()
} 你应该会看到一个好看的 FPS 计量器。
2 - 解除 FPS 限制
有一种方法可以无视屏幕能力,解锁 Chrome 的帧率。
这让你即使在好电脑上也能监控帧率。例如,如果你在一台好电脑上开发,看到 60fps,你可能以为没问题。但也许你的网站在那台好电脑上只能跑到 70~80fps,而在其他电脑上帧率会掉到 60fps 以下,而你不会知道。
如果你解锁帧率限制,你会看到性能其实不够好,在这台电脑上你应该跑到 150~200fps 左右才保险。
要解锁 Chrome 的帧率:
- 把它完全关闭——如果你正在 Chrome 里看这节课,请把下面的步骤写到别的地方。
- 打开终端。
- 打开下面的 Github gist 并启动对应的命令——Mac 或 Windows:https://gist.github.com/brunosimon/c15e7451a802fa8e34c0678620022f7d
Chrome 应该会在没有帧率限制的情况下打开。你可以再次打开 FPS 计量器,在练习里测试它。如果没生效,关闭并重试。如果还是不行,你只能放弃这个方法。
小心;这样做会从你的电脑消耗多得多的功耗,并可能导致 Chrome 崩溃。
3 - 监控 draw call
Draw call 是 GPU 绘制三角形的动作。当我们有一个包含许多物体、几何体、材质等的复杂场景时,就会有很多 draw call。
通常,我们可以说 draw call 越少越好。我们会看一些减少它们的技巧,但首先,我们想监控它们。
有一个很棒的 Chrome 扩展叫 Spector.js,可以帮你做到这一点。
- 安装扩展:https://chrome.google.com/webstore/detail/spectorjs/denbgaamihkadbghdceggmchnflmhpmk
- 在 WebGL 页面上,点击扩展图标激活它。
- 再点一次打开扩展面板。
- 点击红色圆圈录制帧。
稍等一下,会打开一个新页面,里面有关于所录制的帧的许多复杂细节。
在 Commands 标签页里,你会看到帧是如何被一步步绘制的。我们不会在这里解释所有东西,但蓝色的步骤是 draw call,其他步骤通常是发送给 GPU 的数据,比如矩阵、属性、uniform 等。
越少越好。
4 - 渲染器信息
renderer 可以提供一些关于场景里有什么、正在绘制什么的信息。
只需输出 renderer.info 就能得到这些信息:
console.log(renderer.info)
通用
5 - 好的 JavaScript 代码
这一点不言自明,但我们必须保持高性能的原生 JavaScript 代码。这在 tick 函数里更加重要,因为它会在每一帧被调用。
6 - 释放东西
一旦你百分之百确定不再需要某个资源(比如几何体或材质),就释放(dispose)它。如果你创建一个有关卡的游戏,当用户进入下一关时,就把上一关的东西释放掉。
要做到这一点,Three.js 文档里有一个专门的页面:https://threejs.org/docs/#manual/en/introduction/How-to-dispose-of-objects
这是一个用立方体的例子:
scene.remove(cube)
cube.geometry.dispose()
cube.material.dispose()
灯光
7 - 避免使用
如果可能,尽量避免使用 Three.js 灯光。它们有用又易用,但会持续地吃掉电脑的性能。
如果你别无选择,尽量用尽可能少的灯,并用最便宜的,比如 AmbientLight 或 DirectionalLight
8 - 避免添加或移除灯光
当你往场景里添加或移除灯光时,所有支持灯光的材质都必须重新编译。Three.js 就是这么工作的,如果你的场景很复杂,这可能会让屏幕卡顿一下。
阴影
9 - 避免使用
和灯光一样,阴影很方便,但对性能不利。避免它们,并尝试寻找替代方案,比如烘焙阴影(baked shadow)——例如阴影直接烘焙在纹理里的情况。
10 - 优化 shadow map
如果你别无选择,尽量优化 shadow map,让它们看起来不错,又和场景完美贴合。
用 CameraHelper 查看 shadow map 相机会渲染的区域,并把它缩小到尽可能小的区域:
directionalLight.shadow.camera.top = 3
directionalLight.shadow.camera.right = 6
directionalLight.shadow.camera.left = - 6
directionalLight.shadow.camera.bottom = - 3
directionalLight.shadow.camera.far = 10
const cameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(cameraHelper) 另外,在结果尚可的前提下,尽量给 mapSize 用最小的分辨率:
directionalLight.shadow.mapSize.set(1024, 1024)
11 - 聪明地使用 castShadow 和 receiveShadow
有些物体可以投射阴影,有些物体可以接收阴影,有些可能两者都做。尽量只在尽可能少的物体上激活 castShadow 和 receiveShadow:
cube.castShadow = true
cube.receiveShadow = false
torusKnot.castShadow = true
torusKnot.receiveShadow = false
sphere.castShadow = true
sphere.receiveShadow = false
floor.castShadow = false
floor.receiveShadow = true
12 - 停用阴影自动更新
目前,shadow map 会在每次渲染前更新。你可以停用这个自动更新,只在必要时才告诉 Three.js shadow map 需要更新:
renderer.shadowMap.autoUpdate = false
renderer.shadowMap.needsUpdate = true 正如你看到的,我们再也看不到 torusKnot 的阴影旋转了。
纹理
13 - 缩小纹理
纹理会占用大量 GPU 显存。有了 mipmap(自动生成的更小版本,用于缩小过滤和放大过滤)就更糟了。
纹理文件的体积与此无关,只有分辨率才重要。
在保持像样的结果的前提下,尽量把分辨率降到最低。
14 - 保持 2 的幂次分辨率
缩放时,记得保持 2 的幂次分辨率。这对 mipmap 很重要。
分辨率不必是正方形;宽和高可以不同。
如果你不这样做,而渲染又需要 mipmap,Three.js 会尝试通过把图像调整到最接近的 2 的幂次分辨率来修复,但这个过程会消耗资源,并可能导致纹理质量变差。
15 - 用对格式
我们说过格式不会改变 GPU 上的内存占用,但用对格式可以减少加载时间。
你可以根据图像、压缩以及 alpha 通道,选择 .jpg 或 .png。
你可以用像 TinyPNG 这样的在线工具进一步减小体积。你也可以试试像 basis 这样的特殊格式。
Basis 是一种和 .jpg、.png 一样的格式,但压缩更强大,而且 GPU 更容易读取。我们不会讲它,因为生成它相当难,但你想试就试试。你可以在这里找到创建 .basis 文件的信息和工具:https://github.com/BinomialLLC/basis_universal
几何体
16 - 使用 BufferGeometry
旧版 Three.js(125 之前)曾经有“非 buffer”几何体。那些对性能不利,你应该避免它们。
如果你用的是较新的 Three.js 版本,无需担心,所有几何体都是 buffer geometry。
17 - 不要更新顶点
更新几何体的顶点对性能来说很糟糕。你可以在创建几何体时做一次,但要避免在 tick 函数里做。
如果你需要给顶点做动画,用 vertex shader 来做。
18 - 共用几何体
如果你有多个使用相同几何形状的 Mesh,只创建一个几何体,并在所有 mesh 上使用它:
// Tip 17
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
for(let i = 0; i < 50; i++)
{
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(geometry, material)
mesh.position.x = (Math.random() - 0.5) * 10
mesh.position.y = (Math.random() - 0.5) * 10
mesh.position.z = (Math.random() - 0.5) * 10
mesh.rotation.y = (Math.random() - 0.5) * Math.PI * 2
mesh.rotation.z = (Math.random() - 0.5) * Math.PI * 2
scene.add(mesh)
} 你仍然可以改变 Mesh 的位置、旋转和缩放。
19 - 合并几何体
如果几何体不应该移动,你还可以用 BufferGeometryUtils 合并它们。这个类默认不可用,我们需要导入它:
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' 我们不需要实例化它,可以直接使用它的方法。
把几何体数组作为参数用 mergeGeometries(...) 方法,就会返回一个合并后的几何体。然后我们可以在单个 Mesh 上使用那个几何体:
const geometries = []
for(let i = 0; i < 50; i++)
{
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
geometry.rotateX((Math.random() - 0.5) * Math.PI * 2)
geometry.rotateY((Math.random() - 0.5) * Math.PI * 2)
geometry.translate(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
)
geometries.push(geometry)
}
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries)
console.log(mergedGeometry)
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(mergedGeometry, material)
scene.add(mesh) 这更难,因为我们必须改变做事的方式,把所有 mesh 的变换都移到几何体里,但这是值得的,因为最终我们只有一个 draw call。
材质
20 - 共用材质
和几何体一样,如果你在多个 mesh 上使用同一种材质,尽量只创建一个并多次使用它:
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
const material = new THREE.MeshNormalMaterial()
for(let i = 0; i < 50; i++)
{
const mesh = new THREE.Mesh(geometry, material)
mesh.position.x = (Math.random() - 0.5) * 10
mesh.position.y = (Math.random() - 0.5) * 10
mesh.position.z = (Math.random() - 0.5) * 10
mesh.rotation.x = (Math.random() - 0.5) * Math.PI * 2
mesh.rotation.y = (Math.random() - 0.5) * Math.PI * 2
scene.add(mesh)
}
21 - 使用便宜的材质
像 MeshStandardMaterial 或 MeshPhysicalMaterial 这样的材质比 MeshBasicMaterial、MeshLambertMaterial 或 MeshPhongMaterial 之类的材质需要更多资源。
能的时候就用最便宜的材质。
网格
22 - 使用 InstancedMesh
如果你因为需要独立控制各个 mesh 而无法合并几何体,但它们用的是相同的几何体和材质,你可以用 InstancedMesh。
它就像一个 mesh,但你只创建一个 InstancedMesh,然后为该 mesh 的每个“实例”提供一个变换矩阵。
矩阵必须是 Matrix4,你可以用各种可用的方法应用任意变换:
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5)
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.InstancedMesh(geometry, material, 50)
scene.add(mesh)
for(let i = 0; i < 50; i++)
{
const position = new THREE.Vector3(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
)
const quaternion = new THREE.Quaternion()
quaternion.setFromEuler(new THREE.Euler((Math.random() - 0.5) * Math.PI * 2, (Math.random() - 0.5) * Math.PI * 2, 0))
const matrix = new THREE.Matrix4()
matrix.makeRotationFromQuaternion(quaternion)
matrix.setPosition(position)
mesh.setMatrixAt(i, matrix)
} 我们得到一个几乎和合并几何体一样好的结果,但我们仍然可以通过改变矩阵来移动 mesh。
如果你打算在 tick 函数里改变这些矩阵,把下面这行加到 InstancedMesh 上:
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
模型
23 - 低多边形
使用低多边形(low poly)模型。多边形越少,帧率越好。如果你需要细节,试试用 normal map。它们在性能方面很便宜,能以纹理为代价给你带来出色的细节。
24 - Draco 压缩
如果模型有大量细节和非常复杂的几何体,使用 Draco 压缩。它能大幅减少体积。缺点是解压几何体时可能会卡顿,而且你还必须加载 Draco 库。
25 - Gzip
Gzip 是发生在服务器端的压缩。大多数服务器不会对 .glb、.gltf、.obj 等文件做 gzip。
看看你能不能根据自己使用的服务器想出怎么修复这一点。
相机
26 - 视场角
当物体不在视场角内时,它们不会被渲染。这叫做 frustum culling(视锥剔除)。
这看起来可能像个土办法,但你可以直接减小相机的视场角。屏幕上的物体越少,要渲染的三角形就越少。
27 - Near 和 far
和视场角一样,你可以减小相机的 near 和 far 属性。如果你有一个有山、树、建筑等的广阔世界,用户很可能看不见那些藏在山后面远处的小房子。把 far 减到一个合理的值,那些房子甚至都不会尝试被渲染。
渲染器
28 - 像素比
有些设备有非常高的像素比(pixel ratio)。那只是个营销噱头,但要渲染的像素越多,帧率越糟。
尽量把渲染器的像素比限制在 2 左右:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
29 - 功耗偏好
有些设备可能能够在不同 GPU 或不同 GPU 用法之间切换。我们可以在实例化 WebGLRenderer 时通过指定 powerPreference 属性,提示需要多少功耗:
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
powerPreference: 'high-performance'
}) 如果你没有性能问题,把这个属性设为 'default'。
30 - 抗锯齿
默认的抗锯齿(antialias)性能不错,但它仍然比不开抗锯齿慢。只有当你有可见的锯齿且没有性能问题时才开启它。
后处理
31 - 限制 pass 数量
每个后处理 pass 都要按渲染分辨率(包括像素比)那么多像素来渲染。如果你的分辨率是 1920x1080、有 4 个 pass、像素比是 2,那就是 1920 * 2 * 1080 * 2 * 4 = 33 177 600 个像素要渲染。要理性一点,尽量把你的自定义 pass 合并成一个。
Shader
32 - 指定精度
你可以通过修改材质的 precision 属性,强制 shader 的精度:
const shaderMaterial = new THREE.ShaderMaterial({
precision: 'lowp',
// ...
}) 检查结果有没有画质下降或毛刺。
这对 RawShaderMaterial 不管用,你得像第一节课那样自己在 shader 里添加 precision。
33 - 保持代码简单
监控差异很费劲,但尽量让你的 shader 代码尽可能简单。避免 if 语句。好好利用 swizzle 和内置函数。
比如在 vertex shader 里,用这个代替 if 语句:
modelPosition.y += clamp(elevation, 0.5, 1.0) * uDisplacementStrength; 或者在 fragment shader 里,用这个代替 r、g 和 b 那些复杂公式:
vec3 depthColor = vec3(1.0, 0.1, 0.1);
vec3 surfaceColor = vec3(0.1, 0.0, 0.5);
vec3 finalColor = mix(depthColor, surfaceColor, elevation);
34 - 使用纹理
用 perlin noise 函数很酷,但它会显著影响你的性能。有时候,你最好用一张代表噪声的纹理。用 texture2D() 比 perlin noise 函数便宜得多,而且你可以用 photoshop 之类的工具非常高效地制作这些纹理。
35 - 使用 defines
Uniform 很有用,因为我们可以调节它们并在 JavaScript 里给值做动画。但 uniform 有性能开销。如果值不会改变,你可以用 define。创建 define 有两种方式。
直接在 shader 代码里:
#define uDisplacementStrength 1.5 或者在 ShaderMaterial 的 defines 属性里:
const shaderMaterial = new THREE.ShaderMaterial({
// ...
defines:
{
uDisplacementStrength: 1.5
},
// ...
} 如果你用的是 ShaderMaterial,这些 defines 会自动加到 GLSL 代码里。
36 - 在 vertex shader 里做计算
如果可能,在 vertex shader 里做计算,并把结果发给 fragment shader。
// Vertex shader
#define uDisplacementStrength 1.5
uniform sampler2D uDisplacementTexture;
varying vec3 vColor;
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
float elevation = texture2D(uDisplacementTexture, uv).r;
modelPosition.y += clamp(elevation, 0.5, 1.0) * uDisplacementStrength;
gl_Position = projectionMatrix * viewMatrix * modelPosition;
vec3 depthColor = vec3(1.0, 0.1, 0.1);
vec3 surfaceColor = vec3(0.1, 0.0, 0.5);
vec3 finalColor = mix(depthColor, surfaceColor, max(elevation, 0.25));
vColor = finalColor;
}
// Fragment shader
varying vec3 vColor;
void main()
{
gl_FragColor = vec4(vColor, 1.0);
}
继续深入
从一开始就留意性能。在其他设备上测试,使用我们最初看到的工具,并在继续深入之前修复任何奇怪的表现。
每个项目都有不同的约束,应用这些技巧并不总是够用。试着寻找解决方案。改变你做事的方式。聪明一点。
在各个项目中,你会找到更好的做事方式,而且你会适应得更快。到了某个时候,你甚至会知道达到同一结果的多种方法,而你有幸挑选最好的那个。
这里是 Lewy Blue 提供的另一个关于改进 Three.js 使用方式的大清单:https://discoverthreejs.com/tips-and-tricks/